Skip to content

fix(addToCart): send forceNewEntry for items with assembly options (CHK-5575)#218

Merged
mateussaggin merged 2 commits into
mainfrom
fix/addtocart-attachment-merge-collapse-force-new-entry
Jun 12, 2026
Merged

fix(addToCart): send forceNewEntry for items with assembly options (CHK-5575)#218
mateussaggin merged 2 commits into
mainfrom
fix/addtocart-attachment-merge-collapse-force-new-entry

Conversation

@mateussaggin

@mateussaggin mateussaggin commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

addToCart collapses adds carrying options (assembly options / attachments such as B2B quoteData) into the quantity of a pre-existing attachment-less line for the same SKU, instead of creating a distinct line. Diego Chirineá reported this as a Kohler B2B go-live blocker; ticket 1395549 and Jefferson Benedito's investigation confirm the root cause is the two-step flow (clean addItem + addAssemblyOptions) interacting with the checkout engine's merge logic.

This change sets the per-item forceNewEntry flag (added by vtex/vcs.checkout#7039 — CHK-5575) on items that carry options, telling the engine to bypass both its AddItemsAsync merge lookup and the pipeline MergeItems step. The clean addItem then produces a distinct line and the follow-up addAssemblyOptions attaches to it.

Plain adds (items with no options) are unchanged.

Coordination note

Lucas Vysk mentioned in #proj-kohler / #team-b2b that he is also working on this fix. Drafting this PR so we can compare approaches; happy to close in favor of his branch if he prefers.

Backend dependency

This is the GraphQL half of the fix. It needs the engine half — vtex/vcs.checkout#7039 — running on the same environment:

  • Beta: backend is already in release_candidate and tagged v2.577.3-betav2.579.0-beta. Validated live (see below) on diegoio.vtexcommercebeta.com.br — fix works end-to-end.
  • Stable: CHK-5575 is not yet on vcs.checkout main and not in any stable tag. This PR is safe to deploy to stable ahead of the backend (forceNewEntry is silently ignored by older engines — no errors, no behavioral change), but it will only actually fix the bug on stable once vtex/vcs.checkout#7039 reaches main and a stable tag is cut. The two PRs can land independently; the engine fix is the gating one for stable.

What changed

File What
node/resolvers/items.ts In addToCart, cleanItems.map now returns { ...rest, forceNewEntry: true } for items whose input had non-empty options.
node/clients/checkout.ts Checkout.addItem accepts a per-item optional forceNewEntry flag in its type signature.
node/__tests__/items-mutations.test.ts 4 new tests covering: flag is set for items with options; flag is NOT set for plain items (regression); mixed batches; empty options: [] is treated as no-options.
CHANGELOG.md Unreleased### Fixed entry.

Tests (unit)

  • All 203 tests across 13 suites pass locally (yarn test from node/).
  • 4 new explicit tests around forceNewEntry behavior.
  • The pre-existing happy-path test that asserts cleanItems === [{ id, quantity, seller }] for a plain SKU acts as an additional regression guard against accidentally setting forceNewEntry for non-option items.
  • TypeScript: zero new errors introduced (diff vs main is empty for files I touched).

End-to-end validation (live, on real tenants)

Reproduced Diego's exact 4-step scenario directly against the Checkout beta engine, where CHK-5575 is live. The validation script (/tmp/chk5575/simulate_resolver.py / simulate_kohler.py) reproduces the resolver's exact two-phase flow (PATCH /itemsPOST /items/{i}/assemblyOptions/{id}) with a single toggle that adds forceNewEntry: true to phase 1 for items with options — the same one-line change this PR introduces in node/resolvers/items.ts.

Run 1 — diegoio tenant (teste-sabrina-160, SKU 100478601, assembly key quoteId)

Scenario Items after step 4 OrderFormId
Without fix (baseline) 3 — bug repros, quoteId=123Diego-test3 silently dropped, [phase2] no new parent for sku=100478601 -> assemblyOptions SKIPPED c417311ad02549a4a80ae4deba292d60
With fix 4 — all distinct lines present d384638d683e49579d7ac37e0b2ec619

Run 2 — kohlerdev tenant (Venza single-handle bathroom sink faucet, SKU 1937466, assembly key quoteReferenceNumber)

Same SKU, schema, and assembly the Kohler PS team uses (payload sourced from the team's actual order placement: {"isQuote":true,"quoteReferenceNumber":"…"}, allowedOutdatedData:["paymentData"]).

Scenario Items after step 4 OrderFormId
Without fix (baseline) 3 — bug repros, quoteReferenceNumber=Diego-Quote-C silently dropped, [phase2] no new parent for sku=1937466 -> assemblyOptions SKIPPED 2adba4fd4468421d8cf51f6bffff3379
With fix 4 — all distinct lines present 74ce778790494eff801099b8ee887747

Identical bug → identical fix on both tenants. The Kohler schema difference (quoteReferenceNumber vs quoteId) doesn't change the outcome — the engine merge logic operates on SKU + seller + attachment-presence, not on attachment content.

Confirming the beta-routing path

There are two ways to send traffic to the beta Checkout engine (per #team-faststore-dev / #odp-tests internal threads — Frederico Mourão, Lucas Reis, Paladino):

  1. Cookie: add Cookie: vtex-commerce-env=beta to any request — Janus routes regardless of which domain you hit.
  2. Hostname: hit *.vtexcommercebeta.com.br directly.

For IO apps that use @vtex/api's JanusClient (which vtex.checkout-graphql does), the inbound vtex-commerce-env=beta cookie sets ctx.vtex.janusEnv='beta', and JanusClient.js line 20 picks http://portal.vtexcommercebeta.com.br as the outbound base URL. So the cookie propagation Paladino warned about isn't an issue here — the host itself changes, no per-request cookie forwarding required.

Control test proving the cookie routes correctly through this app's Janus path (same workspace, same SKU, same REST call — only the cookie differs). Run on both tenants:

Tenant Cookie forceNewEntry: true honored? Items after two same-SKU adds
diegoio none → stable engine No (silently ignored) 1
diegoio vtex-commerce-env=beta → beta engine Yes (CHK-5575 active) 2
kohlerdev none → stable engine No (silently ignored) 1
kohlerdev vtex-commerce-env=beta → beta engine Yes (CHK-5575 active) 2

This independently corroborates the git-archaeology finding that CHK-5575 (69c02503c3) is on release_candidate + beta tags but not on vcs.checkout main yet.

What this validation does not cover

  • Not exercised through a full vtex link IDE round-trip on this branch: the link build fails on a pre-existing TypeScript 3.9 / @opentelemetry/api ^1.9.0 .d.ts syntax mismatch in node_modules (not in any of our code; would block any link of this repo today). The two-layer coverage — unit tests asserting the exact wire payload + live engine response to that payload — already covers the full chain end-to-end. Once the OTel/builder issue is resolved, the recipe in "Test plan" below also works through vtex link.
  • Validated on kohlerdev (the dev/QA tenant Diego is using) but not on production kohler. Same engine pool, same Catalog, same assembly contract — should be identical — but a final pass on a production-grade Kohler workspace from the PS team is recommended.

Test plan

  • CI green (locally)
  • End-to-end against beta engine on diegoio (Diego's SKU): 4 distinct lines after step 4
  • End-to-end against beta engine on kohlerdev with real Kohler SKU 1937466 (Venza faucet) + real quoteReferenceNumber assembly schema: 4 distinct lines after step 4
  • Control test on both tenants confirming vtex-commerce-env=beta cookie routes to beta Janus through this app (1 item without cookie, 2 items with)
  • PS team sign-off: from kohler.myvtex.com/admin/graphql-ide add vtex-commerce-env=beta cookie via DevTools and replay Diego's 4-step (no beta publish needed once vtex-checkout-graphql with this PR is installed in the workspace)
  • Newman beta regression: /newman run checkout beta
  • Confirm with B2B / subscription teams that no regression observed
  • Confirm backend vtex/vcs.checkout#7039 promoted to stable before flipping this PR to ready-for-stable

Risk

Lowest-impact path consistent with the chosen engine-level fix:

  • No GraphQL schema change.
  • No change to plain-add behavior (guarded by existing test + new explicit test).
  • No change to updateItems, subscription handling, bundle attachments, offerings, manual price.
  • The flag only takes effect for items that today are already broken in the way Diego reported.
  • Forward-safe on stable (unknown field, engine ignores it).

Closes the Kohler-side gap once both PRs are stable.


Additional context (added after deeper review) — client-side cart contract

After validating the fix end-to-end, we took one more pass to understand how this change interacts with the storefront's own consolidation logic, since the engine and the UI both make decisions about merging.

Conclusion: this fix aligns the engine with the long-standing storefront contract.

Where the "merge vs new line" decision actually lives today

For plain (no-attachment) adds, the decision is client-side, in @vtex/order-items (the npm package used by vtex.minicart, vtex.add-to-cart-button, vtex.product-quantity, and the rest of the standard storefront stack). Source on GitHub wraps the npm @vtex/order-items package — relevant code in createOrderItems.tsx#addItems:

// assembly items are always different
const isAssemblyItem = item.options && item.options.length > 0

const existingItem = isAssemblyItem
  ? undefined
  : orderFormItemsRef.current.find((i) => isSameItem(item, i, items))

if (existingItem == null) {
  newList.push(item)                                          // → addToCart mutation
} else {
  updateList.push({
    ...item,
    quantity: (item.quantity ?? 1) + existingItem!.quantity,  // client-side sum
  })                                                          // → updateItems mutation
}

isSameItem matches by SKU + seller + parentItemIndex + parentAssemblyBinding (no attachment-content comparison).

So the storefront's actual decision tree is:

Case What the UI does Mutation called
Plain SKU, NOT in cart "It's new" addToCart
Plain SKU, ALREADY in cart Client-side qty = old + new updateItems
SKU with options (any assembly / quoteData / attachment) "Skip the lookup — always treat as new" (literal comment in the package) addToCart, always

Engine PATCH semantics confirmed empirically against beta (matches docs): for a plain item already in the cart, the engine's /items PATCH replaces the line's quantity with the request value (it doesn't accumulate). The UI never relies on engine accumulation because it pre-sums.

What the engine was doing wrong

For attachment-bearing items, the UI says "always a new line" (it calls addToCart, not updateItems, by design). But the engine — pre-CHK-5575 — would still try to merge the bare phase-1 item into any existing same-SKU+seller line, dropping the attachments in the process. That's the bug. The engine was violating the client-side contract that @vtex/order-items has had for years.

forceNewEntry: true on items with options tells the engine: "honor what the storefront already decided — this is a new line."

Standard B2B quote flow is unaffected

Worth noting separately, because the natural worry on a quote-related fix is "what about b2b-quotes?": vtex.b2b-quotes-graphql's useQuote mutation doesn't use per-item quoteData attachments at all. It (a) clears the cart, (b) groups items by ${id}-${seller} and sums quantities, (c) does a single bulk POST /items, and (d) stores the quote id as orderForm-level custom data. This fix never enters that path. The Kohler integration is a custom storefront that does use per-item quoteData attachments (which is why they hit this bug and standard b2b-quotes users don't).

Explicit semantic change for bespoke storefronts (caveat)

The behavior change is essentially nil for any storefront built on @vtex/order-items (which is "all standard VTEX storefronts"). The one scenario where the change is observable is:

A bespoke storefront calls addToCart directly with the same SKU + same seller + identical attachment content twice, and relies on the engine to silently consolidate them into one line at quantity 2.

With this fix, that scenario now produces two distinct lines at quantity 1 each, instead of one line at quantity 2.

We could not find any storefront in the org that does this (b2b-quotes uses useQuote, standard storefronts use @vtex/order-items which explicitly bypasses consolidation for items with options). But documenting the change explicitly so any team owning a custom add-to-cart path can verify before stable rollout.

Mitigations available if a regression is reported:

  1. Such a storefront should call updateItems (with splitItem: false) to bump quantity, matching the contract for plain items — this is the path @vtex/order-items would take.
  2. Or, the resolver could be refined to look up a same-SKU + same-seller + same-attachments line and route to updateItems instead. We chose not to do this preemptively because it duplicates logic the storefront already owns, and goes against the documented @vtex/order-items contract for assembly items.

cc @diego-chirinea @lvysk @felipe-romero — please flag if you know of any storefront path (Kohler or otherwise) that relies on engine-side same-attachment consolidation through addToCart.

When an item carries `options` (e.g. B2B `quoteData`), `addToCart` issues
two REST calls: a clean addItem followed by addAssemblyOptions. Without a
hint, the checkout engine's merge logic (`AddItemsAsync` plus the pipeline
`MergeItems` step) collapses the clean addItem into any existing line for
the same SKU + seller with no attachments, leaving phase 2 with no new
line to attach the option to. The user-visible symptom is a silent
quantity bump instead of a new distinct line.

CHK-5575 (vtex/vcs.checkout#7039) adds a per-item `forceNewEntry` flag
that bypasses both merge passes. This change sets `forceNewEntry: true`
on cleanItems entries whose input had non-empty `options`, restoring
parity with the REST behavior reported in the Kohler B2B migration.

Plain adds (no `options`) are untouched and continue to merge same-SKU
lines as today, guarded by the existing happy-path test.

Requires vtex/vcs.checkout#7039 in the runtime engine; the flag is
ignored by engines that don't recognize it, so this is forward-compatible.
@vtex-io-ci-cd

vtex-io-ci-cd Bot commented Jun 11, 2026

Copy link
Copy Markdown

Hi! I'm VTEX IO CI/CD Bot and I'll be helping you to publish your app! 🤖

Please select which version do you want to release:

  • Patch (backwards-compatible bug fixes)

  • Minor (backwards-compatible functionality)

  • Major (incompatible API changes)

And then you just need to merge your PR when you are ready! There is no need to create a release commit/tag.

  • No thanks, I would rather do it manually 😞

@vtex-io-docs-bot

vtex-io-docs-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown

Beep boop 🤖

I noticed you didn't make any changes at the docs/ folder

  • There's nothing new to document 🤔
  • I'll do it later 😞

In order to keep track, I'll create an issue if you decide now is not a good time

  • I just updated 🎉🎉

@lucvysk lucvysk marked this pull request as ready for review June 12, 2026 12:36
@lucvysk lucvysk requested a review from a team as a code owner June 12, 2026 12:37
@lucvysk lucvysk requested review from fdaciuk and jeffersontuc June 12, 2026 12:37
@mateussaggin mateussaggin merged commit 1aabeda into main Jun 12, 2026
8 checks passed
@mateussaggin mateussaggin deleted the fix/addtocart-attachment-merge-collapse-force-new-entry branch June 12, 2026 23:56
@vtex-io-ci-cd

vtex-io-ci-cd Bot commented Jun 12, 2026

Copy link
Copy Markdown

Your PR has been merged! App is being published. 🚀
Version 0.67.2 → 0.68.0

After the publishing process has been completed (check #vtex-io-releases) and doing A/B tests with the new version, you can deploy your release by running:

vtex deploy vtex.checkout-graphql@0.68.0

After that your app will be updated on all accounts.

For more information on the deployment process check the docs. 📖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants